Amplify Gen2 のStorageを試してみた
NTT東日本の中村です。
昨年発表されたAmplify Gen2(プレビュー)ですが、日々機能が少しづつ追加されており、完成度が高まってきました。 今回もNextJSのWebアプリケーションを使って、Amplify Storageの調査を行いました。
Amplify Gen2のStorageの概要
ファイルのアップロード・ダウンロードが行えるようになる機能です。Gen1から存在している機能を引き継いでいます。
既存のAmplifyでは、Storageカテゴリはファイルのストレージ(S3でファイルのアップロード・ダウンロード)、データのストレージ(DynamoDB)の2つの概念があり、CLIで「amplify add storage」を実行すると、選択が必要です。
Amplify Gen2では、執筆時点ではファイルのストレージのみ対応しています。
Gen2に変わったことで、Storageのファイルストレージ機能がどのように変化したか調査してみました。 以降は、現行のAmplify V6を主な比較対象としています。
ファイルストレージの概要
Amplify Storageのファイルストレージは、基本的な機能に変化はありませんが、考え方が大きく変わりました。
変わらない所・変わった所
基本的な機能の部分に変化はありません。
- 使用すると、S3が追加される
- Amplify Authログインでの認証、ゲストユーザ認証、 Cognito Groups認証でのアクセス制御ができる
- Lambda Triggerを追加できる
アクセスのルールについては、public、private、protectedの概念をやめ、パスベースのきめ細やかなルールを設定できます。 根底のS3は同じですが、アクセスの仕方が大きく変わっています。
既存のAmplifyは、3つの認証のカテゴリに対して、read, write, deleteのアクションが可能かどうかを設定します。
- private:認証済みユーザ
- guest:認証無しユーザ
- groups:ユーザーグループ
ファイルストレージ側は
- public:ゲストユーザを含めたユーザーがアクセス可能
- protected:全てのユーザが読み取り可能で、作成ユーザのみ編集できる
- private: 自分だけが読み取り・編集できる
の、3つのアクセスのルールを設定します。このルールを元に、オブジェクトの起点のフォルダが決まります。 この認証カテゴリとアクセスのルールを別々の所(CLIとAPI)で設定するのが若干複雑だと感じていました。
一方のGen2ですが、自由度が増してスッキリした印象です。
- デフォルトの3つのアクセスのルールが無くなり、オブジェクトのパスに対して、「どの認証カテゴリに、どのアクションを適用するか」を自分で設定するようになりました。
- 認証のカテゴリが少し表現が変わりました(Amplify Dataでの認可戦略の考え方とほぼ同様です)
- authenticated:認証済みユーザ
- guest:認証無しユーザ
- group:ユーザーグループ
- (owner):オブジェクトの所有者
- (custom):defineFunctionで定義したロジックに従って許可・不許可を判断する
これらがresource.tsの中だけで全て設定できるのが、解りやすくて良い所だと思います。
例えば、「foo配下は、認証済みユーザのみreadできる」という場合、下記のように書くことができます。
export const storage = defineStorage({ name: 'myProjectFiles', access: (allow) => ({ 'foo/*': [allow.authenticated.to(['read'])] }) });
変更点の中で、気になった2点をピックアップしました。
ownerアクセス
所有者ベースのストレージは、従来のprivateに近いものと考えると、「自分だけが'read, write, deleteできる」と考えられ、構築の際にはオブジェクトパスにentity id(user_identity_id)を含める必要があります。
allow.entity()を使用し、所有者の認証カテゴリに対し、アクションを設定します。
export const storage = defineStorage({ name: 'myProjectFiles', access: (allow) => ({ 'foo/{entity_id}/*': [ allow.entity('identity').to(['read', 'write', 'delete']) ] }) });
ルールを緩和し、「自分のみ編集可能、他の人は見るだけならOK」(従来のProtected)としたい場合は、許可する権限を追加します。
この例では、認証済みユーザ、認証なしユーザにreadを付与しています。
export const storage = defineStorage({ name: 'myProjectFiles', access: (allow) => ({ 'foo/{entity_id}/*': [ allow.entity('identity').to(['read', 'write', 'delete']) allow.guest.to(['read']), allow.authenticated.to(['read']) ] }) });
オブジェクトのパス階層のアクセス
パスの階層レベルごとにアクセス制御を行うことができます。
export const storage = defineStorage({ name: 'myProjectFiles', access: (allow) => ({ 'foo/*': [allow.authenticated.to(['read', 'write', 'delete'])], 'foo/bar/*': [allow.guest.to(['read'])], 'foo/baz/*': [allow.authenticated.to(['read'])], 'other/*': [ allow.guest.to(['read']), allow.authenticated.to(['read', 'write']) ] }) });
- ネストしてアクセス制御できるのは1階層まで
- サブパスを定義した場合、サブパスのアクセス制御だけが有効になり、親のアクセス制御は継承されない
だけ、気にしておけば良さそうです。
ちなみに既存のAmplifyも、格納先のパスのカスタマイズは可能でしたが、IAMポリシーの追加が必要でした。Gen2では、IAMの設定も自動で行います(後ほど説明します)
Gen1のアクセスパターンのテンプレート
既存のAmplifyと同様のルール(public、protected、private)で使いたい人のテンプレートです。
export const storage = defineStorage({ name: 'myProjectFiles', access: (allow) => ({ 'public/*': [ allow.guest.to(['read']) allow.authenticated.to(['read', 'write', 'delete']), ], 'protected/{entity_id}/*': [ allow.authenticated.to(['read']), allow.entity('identity').to(['read', 'write', 'delete']) ], 'private/{entity_id}/*': [allow.entity('identity').to(['read', 'write', 'delete'])] }) });
やってみた
アクセスのルールの定義と、実際にアップロード・ダウンロードを行い、アクセスが可能か確認します。
バックエンドの構築
アクセスのルールを定義してみました。
- /admin直下は、adminグループのみread, write, delete
- /content/{{entity_id}}所有者ベースのストレージを用意し、所有者とadminグループのみread, write, delete。ゲストユーザ及び認証済ユーザはread。
import { defineStorage } from "@aws-amplify/backend"; export const storage = defineStorage({ name: "myProjectFiles", access: (allow) => ({ "admin/*": [allow.group("admin").to(["read", "write", "delete"])], "content/{entity_id}/*": [ allow.entity("identity").to(["read", "write", "delete"]), allow.group("admin").to(["read", "write", "delete"]), allow.guest.to(["read"]), allow.authenticated.to(["read"]), ], }), });
ユーザーグループは、auth/resource.tsで定義します。 以前cfnでグループを定義したのですが、defineAuthで定義しないと、グループに紐づくRoleが生成されず、storageとの連携時にcdkの更新に失敗してしまいます。
import { defineAuth } from "@aws-amplify/backend"; /** * Define and configure your auth resource * @see https://docs.amplify.aws/gen2/build-a-backend/auth */ export const auth = defineAuth({ loginWith: { email: { verificationEmailSubject: "Welcome! Verify your email!", }, }, groups: ["worker", "admin"], });
パスベースでアクセス制御を行った結果、生成されるIAMポリシーをチェックします。 Cognitoベースの認証を用いているので、authStackで生成されるそれぞれのIAMポリシーに、アクセス制御設定が追加されています。
- authenticatedUserRole(認証済)
- unauthenticatedUserRole(ゲスト)
- workerGroupRole(userGroup"worker")
- adminGroupRole(userGroup"admin")
抜粋して、adminGroupRoleとauthenticatedUserRoleを見てみます。
それぞれのIAMポリシーの中で、GetObject、ListBucket、PutObject、DeleteObjectとアクション事にStatementが分かれており、パスベースのルールがアクションベースに落とし込まれていることが分かりますね。
adminGroupRole
{ "Version": "2012-10-17", "Statement": [ { "Action": "s3:GetObject", "Resource": [ "arn:aws:s3:::amplify-nextamplifygen2-n-hogehoge-vjbpkjzb6b9r/admin/*", "arn:aws:s3:::amplify-nextamplifygen2-n-hogehoge-vjbpkjzb6b9r/content/*/*" ], "Effect": "Allow" }, { "Condition": { "StringLike": { "s3:prefix": [ "admin/*", "admin/", "content/*/*", "content/*/" ] } }, "Action": "s3:ListBucket", "Resource": "arn:aws:s3:::amplify-nextamplifygen2-n-hogehoge-vjbpkjzb6b9r", "Effect": "Allow" }, { "Action": "s3:PutObject", "Resource": [ "arn:aws:s3:::amplify-nextamplifygen2-n-hogehoge-vjbpkjzb6b9r/admin/*", "arn:aws:s3:::amplify-nextamplifygen2-n-hogehoge-vjbpkjzb6b9r/content/*/*" ], "Effect": "Allow" }, { "Action": "s3:DeleteObject", "Resource": [ "arn:aws:s3:::amplify-nextamplifygen2-n-hogehoge-vjbpkjzb6b9r/admin/*", "arn:aws:s3:::amplify-nextamplifygen2-n-hogehoge-vjbpkjzb6b9r/content/*/*" ], "Effect": "Allow" } ] }
authenticatedUserRole
{ "Version": "2012-10-17", "Statement": [ { "Action": "s3:GetObject", "Resource": [ "arn:aws:s3:::amplify-nextamplifygen2-n-hogehoge-vjbpkjzb6b9r/content/${cognito-identity.amazonaws.com:sub}/*", "arn:aws:s3:::amplify-nextamplifygen2-n-hogehoge-vjbpkjzb6b9r/content/*/*" ], "Effect": "Allow" }, { "Condition": { "StringLike": { "s3:prefix": [ "content/${cognito-identity.amazonaws.com:sub}/*", "content/${cognito-identity.amazonaws.com:sub}/", "content/*/*", "content/*/" ] } }, "Action": "s3:ListBucket", "Resource": "arn:aws:s3:::amplify-nextamplifygen2-n-hogehoge-vjbpkjzb6b9r", "Effect": "Allow" }, { "Action": "s3:PutObject", "Resource": "arn:aws:s3:::amplify-nextamplifygen2-n-hogehoge-vjbpkjzb6b9r/content/${cognito-identity.amazonaws.com:sub}/*", "Effect": "Allow" }, { "Action": "s3:DeleteObject", "Resource": "arn:aws:s3:::amplify-nextamplifygen2-n-hogehoge-vjbpkjzb6b9r/content/${cognito-identity.amazonaws.com:sub}/*", "Effect": "Allow" } ] }
フロントエンドの構築
Gen2のアップロード・ダウンロードAPIというものが存在しないため、V6のDocmentを参考にします。
V6のDocmentを参考にします。
また、Amplify-UIのConnected Componentsの挙動もチェックしてみました。
注意点
V6のAPI、Amplify-UIのConnected Componentsいずれも、accessLevelというオプションを定義する必要があります。 このオプションにはStorageAccessLevelという、V6以前のルールに則った型が適用されます。
export type StorageAccessLevel = 'guest' | 'protected' | 'private';
この値を元に、S3へファイルを保存する時の起点となるフォルダ名が決定します。
- guest→public
- protected→protected/{entity_id}/*
- private→private/{entity_id}/*
といった具合です。
しかし、Gen2では起点のフォルダ名に、protectedやprivate以外も使いたい訳ですね。 その場合、accessLevelに任意の値を指定し、prefixResolverで任意の値に対応するパスを返すようにすると、任意のパスにアップロードを行えるようになります。
今回は、admin、contentの2つのアクセスレベルを設定しました。 なお、現時点ではStorageAccessLevelにInvalidな文字列を与えることになるので、TypeScriptの型エラーが発生します。留意が必要です。
Amplify.configure(amplifyconfig, { Storage: { S3: { prefixResolver: async ({ accessLevel, targetIdentityId }) => { // @ts-ignore if (accessLevel === "admin") { return "admin/"; } else if (accessLevel === "content") { return `content/${targetIdentityId}/`; } else { return `content/${targetIdentityId}/`; } }, }, }, });
以上を考慮して、コンポーネントを作成しています。
"use client"; import { useEffect, useState } from "react"; import { Amplify } from "aws-amplify"; import type { StorageAccessLevel } from "@aws-amplify/core"; import { getCurrentUser } from "aws-amplify/auth"; import { list, downloadData } from "aws-amplify/storage"; import { StorageImage,StorageManager } from "@aws-amplify/ui-react-storage"; import "@aws-amplify/ui-react/styles.css"; import amplifyconfig from "../amplifyconfiguration.json"; const ACCESS_LEVEL: StorageAccessLevel = "admin" as StorageAccessLevel; Amplify.configure(amplifyconfig, { Storage: { S3: { prefixResolver: async ({ accessLevel, targetIdentityId }) => { // @ts-ignore if (accessLevel === "admin") { return "admin/"; } else if (accessLevel === "content") { return `content/${targetIdentityId}/`; } else { return `content/${targetIdentityId}/`; } }, }, }, }); const download = async (key: string) => { const task = downloadData({ key, options: { accessLevel: ACCESS_LEVEL, }, }); const { body } = await task.result; const blob = new Blob([await body.blob()]); const link = document.createElement("a"); link.download = key; link.href = URL.createObjectURL(blob); link.click(); URL.revokeObjectURL(link.href); }; export function App() { const [user, setUser] = useState(""); const [fileList, setFileList] = useState<string[]>([]); useEffect(() => { (async () => setUser((await getCurrentUser()).signInDetails?.loginId ?? ""))(); }, []); useEffect(() => { (async () => { const response = await list({ options: { accessLevel: ACCESS_LEVEL, }, }); setFileList(response.items.map((item) => item.key)); })(); }, []); return ( <div style={{ padding: "2rem" }}> <span>{user}</span> <h2>accessLevel:{ACCESS_LEVEL}(custom)</h2> <div style={{ margin: "0 2rem" }}> <StorageManager acceptedFileTypes={["image/*"]} accessLevel={ACCESS_LEVEL} maxFileCount={1} /> </div> <h2>{ACCESS_LEVEL} file list</h2> <ul style={{ margin: "0 2rem" }}> {fileList.map((key, _i) => ( <li key={_i}> {key} <button onClick={() => download(key)}>download</button> <StorageImage alt={key} imgKey={key} accessLevel={ACCESS_LEVEL} /> </li> ))} </ul> </div> ); } export default App;
では、挙動を確認してみます。
まず、CognitoのGroupがadminであるユーザでログインし、画像をアップロードしました。
adminのaccessLevelを設定しているので、ファイルはS3のaccess/直下に保存されます。 adminGroupにはRead/Writeの権限があるので、アップロードと、リスト取得、表示、ダウンロードを行うことができます。
S3上で、admin直下にファイルが保存されていることを確認できます。
次に、user01でログインを行います。 user01はグループに所属していないので、認証カテゴリとしてはauthenticatedに相当します。
この権限でaccessLevel:adminでアクセスしますが、adminのアクセスルールでは、authenticatedは許可されていません。
そのため、adminパスへのファイルの取得や、表示に失敗していることが分かります。
このように、ユーザの認証カテゴリによりアクセスルールが正しく機能し、表示を制御できることが分かりました。
まとめ
Amplify Gen2のStorage機能はパスベースの細かな設定が直感的に行えるようになり、データをセキュアに保つ上で使い勝手が良くなりました。APIやAmplify UIの正式対応が待ち遠しいですね!